Add inline object caching to HPPS repositories#7904
Conversation
Introduces a shared trait mirroring WooCommerce's CacheNameSpaceTrait that provides prefix-based cache group invalidation. This enables rotating a prefix key to make all cached entries in a group unreachable without requiring wp_cache_flush_group(), which is not universally available. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds a filterable feature flag that gates all HPPS cache operations. Defaults to enabled when tables-based storage is active, controllable via the sensei_hpps_cache_enabled filter. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds read-through object caching with sentinel values to the course, lesson, and quiz progress Tables_Based repositories. Refactors has() to delegate to get() for cache reuse. find() warms individual caches via wp_cache_set_multiple(). Bulk deletes use prefix rotation for group invalidation. Filter cleanup moved to tearDown() to prevent leakage on assertion failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Adds read-through object caching to the submission, answer, and grade Tables_Based repositories. Submissions cache get() with per-key invalidation on save/delete. Answers and grades cache get_all() by submission_id, with cache deletion on create/delete_all/save_many. Filter cleanup moved to tearDown() to prevent leakage on assertion failure. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…Prefix trait - Extract '__not_found__' magic string to $cache_not_found static property for centralized sentinel management across all consuming repositories - Replace wp_cache_set with wp_cache_add + re-read in get_cache_prefix() to mitigate thundering herd when the prefix key is evicted - Add @SInCE 4.24.0 tags to all three trait methods Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Cache the filter result in a static property so is_cache_enabled() returns a consistent value throughout the request. Add reset_cache_enabled() for test teardown. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Use trait sentinel property instead of magic string - Skip cache population on failed DB insert (id=0) - Remove duplicate filter from has() since get() already filters - Hoist is_cache_enabled() call out of find() loop - Add @SInCE 4.24.0 to CACHE_GROUP constants - Fix @intenal typos and wrong constructor names in docblocks Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add tests for answer create cache invalidation, grade create and save_many cache invalidation, and has() delegation to get() for lesson/quiz progress. Add reset_cache_enabled() calls in tearDown to prevent filter leakage between tests. Rename $array parameter to $data to satisfy PHPCS naming rules. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add native string type to $cache_not_found, add false fallback in get_cache_prefix() re-read, add sentinel overwrite tests for all 3 progress repos, add is_cache_enabled() unit tests, and add failed-insert-no-cache test. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Introduces request-scoped object caching for HPPS (tables-based) progress/submission repositories to reduce repeated DB reads within a request, with a shared cache-prefix invalidation mechanism and a feature-flag gate.
Changes:
- Added
Cache_Prefixtrait to support cache-group invalidation via prefix rotation plus a not-found sentinel. - Implemented inline read-through caching + targeted invalidation in tables-based repositories (course/lesson/quiz progress, submissions, answers, grades).
- Added unit/integration tests for cache enablement, memoization, invalidation behavior, and prefix logic.
Reviewed changes
Copilot reviewed 17 out of 17 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
includes/internal/trait-cache-prefix.php |
New shared trait for prefix-based cache namespacing and group invalidation. |
includes/internal/services/class-progress-storage-settings.php |
Adds memoized is_cache_enabled() feature flag with sensei_hpps_cache_enabled filter and test reset helper. |
includes/internal/student-progress/course-progress/repositories/class-tables-based-course-progress-repository.php |
Adds inline caching for get() and warming in find(), plus invalidation on writes/deletes. |
includes/internal/student-progress/lesson-progress/repositories/class-tables-based-lesson-progress-repository.php |
Same inline caching + invalidation for lesson progress repository. |
includes/internal/student-progress/quiz-progress/repositories/class-tables-based-quiz-progress-repository.php |
Same inline caching + invalidation for quiz progress repository. |
includes/internal/quiz-submission/submission/repositories/class-tables-based-submission-repository.php |
Adds caching/invalidation for submissions get() and write paths. |
includes/internal/quiz-submission/answer/repositories/class-tables-based-answer-repository.php |
Adds caching/invalidation for answers get_all() and write paths. |
includes/internal/quiz-submission/grade/repositories/class-tables-based-grade-repository.php |
Adds caching/invalidation for grades get_all() and write paths. |
tests/unit-tests/internal/test-class-cache-prefix.php |
New tests covering cache prefix generation and invalidation behavior. |
tests/unit-tests/internal/services/test-class-progress-storage-settings.php |
Tests new cache-enabled flag defaults, filter override, and memoization/reset. |
tests/unit-tests/internal/student-progress/course-progress/repositories/test-class-tables-based-course-progress-repository.php |
Adds caching/invalidation tests for course progress repository. |
tests/unit-tests/internal/student-progress/lesson-progress/repositories/test-class-tables-based-lesson-progress-repository.php |
Adds caching/invalidation tests for lesson progress repository. |
tests/unit-tests/internal/student-progress/quiz-progress/repositories/test-class-tables-based-quiz-progress-repository.php |
Adds caching/invalidation tests for quiz progress repository. |
tests/unit-tests/internal/quiz-submission/submission/repositories/test-class-tables-based-submission-repository.php |
Adds caching/invalidation tests for submission repository. |
tests/unit-tests/internal/quiz-submission/answer/repositories/test-class-tables-based-answer-repository.php |
Adds caching/invalidation tests for answer repository. |
tests/unit-tests/internal/quiz-submission/grade/repositories/test-class-tables-based-grade-repository.php |
Adds caching/invalidation tests for grade repository. |
changelog/add-hpps-inline-caching |
Changelog entry for the performance change. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
...dent-progress/lesson-progress/repositories/class-tables-based-lesson-progress-repository.php
Show resolved
Hide resolved
.../student-progress/quiz-progress/repositories/class-tables-based-quiz-progress-repository.php
Show resolved
Hide resolved
...ent-progress/quiz-progress/repositories/test-class-tables-based-quiz-progress-repository.php
Outdated
Show resolved
Hide resolved
...al/quiz-submission/submission/repositories/test-class-tables-based-submission-repository.php
Outdated
Show resolved
Hide resolved
...progress/course-progress/repositories/test-class-tables-based-course-progress-repository.php
Outdated
Show resolved
Hide resolved
...progress/lesson-progress/repositories/test-class-tables-based-lesson-progress-repository.php
Outdated
Show resolved
Hide resolved
...s/internal/quiz-submission/answer/repositories/test-class-tables-based-answer-repository.php
Outdated
Show resolved
Hide resolved
...sts/internal/quiz-submission/grade/repositories/test-class-tables-based-grade-repository.php
Outdated
Show resolved
Hide resolved
...dent-progress/course-progress/repositories/class-tables-based-course-progress-repository.php
Outdated
Show resolved
Hide resolved
The get_all() methods in answer and grade repositories return [] for empty results. Since [] !== false, empty arrays can be cached directly without the __not_found__ sentinel, which is only needed for methods that return null. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Reintroduce the sensei_*_progress_has_*_id filters in all three progress repository has() methods — these are public hooks since 4.23.1 used by WPML and other integrations. Removing them was a backward-incompatible regression. Fix CacheDisabled_DoesNotCache tests in all 6 repositories to assert on the cache prefix marker key instead of an un-prefixed key, which would always return false regardless of whether caching occurred. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
… trait Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The cache group already contains 'sensei_', so the prefix key was producing 'sensei_sensei_...' unnecessarily. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Also add AGENTS.md rule requiring assertion messages when a test has multiple assertions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replace get() delegation with a direct SELECT COUNT(*) query in course, lesson, and quiz tables-based progress repositories. This avoids fetching all columns, hydrating full entity objects, and populating the cache when callers only need a boolean existence check. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Remove testHas_CacheEnabled_DelegatesToGet from all three progress repository test files since has() no longer delegates to get(). Reorganize tests so all tests for each method (unit + cache) are grouped together instead of having cache tests in a separate section. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Convert read-caching tests (ReturnsCachedValueOnSecondCall, CachesNullAsNotFound/CachesEmptyResult, CreateOverwritesNotFoundSentinel, WarmsIndividualCaches) from integration tests using global $wpdb to unit tests using mocked wpdb with expects() assertions. This proves the second call actually hits cache instead of DB, which the previous tests could not verify. Also removes redundant get() calls in invalidation tests where create() already warms the cache via write-through caching. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
|
@merkushin This one adds caching. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| @@ -154,6 +174,15 @@ public function get( int $quiz_id, int $user_id ): ?Submission_Interface { | |||
| */ | |||
| $quiz_id = apply_filters( 'sensei_quiz_submission_get_quiz_id', $quiz_id ); | |||
There was a problem hiding this comment.
In get(), the filtered $quiz_id is not cast back to an int before being used to build the cache key (and later passed into a %d placeholder). If a filter returns a numeric string or other non-int scalar, this can create cache-key mismatches (and potentially type coercion warnings) where the DB query uses the int-cast value but the cache key uses the uncast value. Align with the progress repositories by casting the filtered value to (int) immediately after apply_filters() and before computing $cache_key / preparing the query.
| $quiz_id = apply_filters( 'sensei_quiz_submission_get_quiz_id', $quiz_id ); | |
| $quiz_id = apply_filters( 'sensei_quiz_submission_get_quiz_id', $quiz_id ); | |
| $quiz_id = (int) $quiz_id; |
There was a problem hiding this comment.
As a matter of style, I'd prefer:
$quiz_id = (int) apply_filters( 'sensei_quiz_submission_get_quiz_id', $quiz_id );
There was a problem hiding this comment.
Fixed — added (int) cast to all apply_filters calls in both the tables-based and comments-based submission repositories (create(), get_or_create(), get()), using the inline style.
| ); | ||
|
|
||
| if ( $this->wpdb->insert_id && Progress_Storage_Settings::is_cache_enabled() ) { | ||
| $cache_key = $quiz_id . '_' . $user_id; |
There was a problem hiding this comment.
Everywhere here, I'd prefer to extract the cache key building into a separate function.
There was a problem hiding this comment.
Done — added a private get_cache_key() method to each of the 6 tables-based repositories. Each has the appropriate signature for its key shape (progress repos take entity ID + user ID, answer/grade repos take just submission ID).
|
Overall, the solution is straightforward but fine. The original idea was to have caching wrappers: simpler logic, separate concern. |
…Memcached keys microtime() returns "0.12345600 1712345678" which contains a space. While most WordPress Memcached drop-ins sanitize keys, not all do. Stripping the space defensively ensures valid keys with any backend. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Ensures filtered values are cast to int before use in cache keys and queries, matching the pattern used in all other repositories. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Replaces inline cache key construction with a private method per repository, making the key format a single source of truth. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 19 out of 19 changed files in this pull request and generated 5 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
|
|
||
| if ( Progress_Storage_Settings::is_cache_enabled() ) { | ||
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission->get_id() ), self::CACHE_GROUP ), self::CACHE_GROUP ); |
There was a problem hiding this comment.
save_many() invalidates the cached get_all() result using $submission->get_id(), but get_all() caches under a key derived from the filtered submission ID (sensei_quiz_grade_get_all_submission_id). If the filter changes the ID, the cache entry being read won’t be the one being deleted. Suggest deriving the cache key for invalidation via the same filter/key helper as get_all().
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission->get_id() ), self::CACHE_GROUP ), self::CACHE_GROUP ); | |
| $submission_id = (int) apply_filters( 'sensei_quiz_grade_get_all_submission_id', $submission->get_id(), 'tables' ); | |
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission_id ), self::CACHE_GROUP ), self::CACHE_GROUP ); |
| $this->wpdb->query( $this->wpdb->prepare( $delete_query, ...$answer_ids ) ); | ||
|
|
||
| if ( Progress_Storage_Settings::is_cache_enabled() ) { | ||
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission_id ), self::CACHE_GROUP ), self::CACHE_GROUP ); |
There was a problem hiding this comment.
delete_all() deletes the cache entry using the ID filtered by sensei_quiz_grade_delete_all_submission_id, but get_all() reads/writes cache using an ID filtered by sensei_quiz_grade_get_all_submission_id. If those filters return different values, the cached grades list may not be invalidated correctly after deletes. Consider computing invalidation keys using the same ID normalization logic as get_all() (shared helper / apply the same filter) to guarantee consistency.
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission_id ), self::CACHE_GROUP ), self::CACHE_GROUP ); | |
| $cache_submission_id = (int) apply_filters( 'sensei_quiz_grade_get_all_submission_id', $submission->get_id(), 'tables' ); | |
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $cache_submission_id ), self::CACHE_GROUP ), self::CACHE_GROUP ); |
| ); | ||
|
|
||
| if ( $this->wpdb->insert_id && Progress_Storage_Settings::is_cache_enabled() ) { | ||
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission_id ), self::CACHE_GROUP ), self::CACHE_GROUP ); |
There was a problem hiding this comment.
Cache invalidation uses $submission_id as filtered by sensei_quiz_answer_create_submission_id, but get_all() caches under a key derived from sensei_quiz_answer_get_all_submission_id. If those filters ever map IDs differently, create() may fail to invalidate the get_all() cache, causing stale reads. Consider deriving the cache key for invalidation using the same ID-normalization logic as get_all() (e.g., apply the get_all filter or centralize key computation in a helper used by both).
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission_id ), self::CACHE_GROUP ), self::CACHE_GROUP ); | |
| /** | |
| * Normalize the submission ID for cache key computation in the same way as get_all(). | |
| * | |
| * This must stay in sync with the logic in get_all(), which uses the | |
| * 'sensei_quiz_answer_get_all_submission_id' filter when generating the cache key. | |
| */ | |
| $cache_submission_id = (int) apply_filters( 'sensei_quiz_answer_get_all_submission_id', $submission_id, 'tables' ); | |
| wp_cache_delete( | |
| self::get_prefixed_key( $this->get_cache_key( $cache_submission_id ), self::CACHE_GROUP ), | |
| self::CACHE_GROUP | |
| ); |
| ); | ||
|
|
||
| if ( Progress_Storage_Settings::is_cache_enabled() ) { | ||
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission_id ), self::CACHE_GROUP ), self::CACHE_GROUP ); |
There was a problem hiding this comment.
delete_all() invalidates the cache key using the ID filtered by sensei_quiz_answer_delete_all_submission_id, but get_all() caches under a key derived from sensei_quiz_answer_get_all_submission_id. If those filters diverge, the cached answers list may not be invalidated correctly. Suggest computing the invalidation key using the same ID transformation/key helper as get_all() so cache keys stay consistent across read/write paths.
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission_id ), self::CACHE_GROUP ), self::CACHE_GROUP ); | |
| // Use the same ID transformation as get_all() when computing the cache key. | |
| $cache_submission_id = (int) apply_filters( 'sensei_quiz_answer_get_all_submission_id', $submission->get_id(), 'tables' ); | |
| wp_cache_delete( | |
| self::get_prefixed_key( $this->get_cache_key( $cache_submission_id ), self::CACHE_GROUP ), | |
| self::CACHE_GROUP | |
| ); |
| ); | ||
|
|
||
| if ( $this->wpdb->insert_id && Progress_Storage_Settings::is_cache_enabled() ) { | ||
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission->get_id() ), self::CACHE_GROUP ), self::CACHE_GROUP ); |
There was a problem hiding this comment.
create() invalidates the get_all() cache using $submission->get_id() directly, but get_all() caches under a key derived from sensei_quiz_grade_get_all_submission_id. If that filter maps submission IDs (e.g., translation/aliasing), cache invalidation can miss and serve stale grades. Consider generating the invalidation key using the same ID normalization as get_all() (apply the same filter or centralize cache-key computation).
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission->get_id() ), self::CACHE_GROUP ), self::CACHE_GROUP ); | |
| $submission_id_for_cache = apply_filters( 'sensei_quiz_grade_get_all_submission_id', $submission->get_id() ); | |
| wp_cache_delete( self::get_prefixed_key( $this->get_cache_key( $submission_id_for_cache ), self::CACHE_GROUP ), self::CACHE_GROUP ); |
@merkushin The first approach I took was to use caching decorators, but I didn't like the number of extra files it added, plus there were issues with flushing the cache so I decided to pivot to Woo's approach. |
Do you remember what the issue was? (My personal interest.) |
Claude wanted to use |
Proposed Changes
Adds inline object caching to all six HPPS (High-Performance Progress Storage)
Tables_Basedrepositories, improving read performance by avoiding repeated database queries for the same progress/submission data within a request.Architecture:
Cache_Prefixtrait — Shared cache infrastructure with prefix-rotation for group invalidation (modeled after WooCommerce'sCacheNameSpaceTrait). Useswp_cache_add()+ re-read to mitigate thundering herd on prefix initialization.get(),get_all()). Write methods (create(),save(),delete()) invalidate individual entries; bulk deletes rotate the group prefix.Progress_Storage_Settings::is_cache_enabled(), which defaults totruewhen HPPS tables are active. Filterable viasensei_hpps_cache_enabled. Memoized to prevent mid-request instability.'__not_found__'sentinel distinguishes "cached null" from "cache miss", preventing repeated DB queries for non-existent entities.find()cache warming — After DB queries,find()warms individual caches for returned objects without caching the query itself.Repositories cached:
sensei_course_progress{course_id}_{user_id}sensei_lesson_progress{lesson_id}_{user_id}sensei_quiz_progress{quiz_id}_{user_id}sensei_quiz_submissions{quiz_id}_{user_id}sensei_quiz_answers{submission_id}sensei_quiz_grades{submission_id}Not cached (by design):
has(),find()query results,count(),get_question_ids()— complex/cross-table queries where invalidation would be fragile, or lightweight queries where caching adds unnecessary overhead.Testing Instructions
Prerequisites: Enable HPPS: WP Admin → Sensei → Settings → Experimental → set progress storage to Custom tables. Install Query Monitor for query inspection.
1. No stale data after completing a lesson
2. Quiz submission flow
3. Query reduction with persistent object cache (optional, requires Redis or Memcached)
wp_sensei_lms_progressNew/Updated Hooks
sensei_hpps_cache_enabled(filter) — Controls whether HPPS repository caching is active. Receives a boolean (default: result ofis_tables_repository()). Returnfalseto disable caching.🤖 Generated with Claude Code